| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634 |
- "use client";
- import { useEffect, useState } from "react";
- import { useSession } from "next-auth/react";
- import { redirect, useRouter } from "next/navigation";
- import Link from "next/link";
- import AuthenticatedLayout from "@/components/AuthenticatedLayout";
- import { AppointmentStatusBadge } from "@/components/appointments/AppointmentStatusBadge";
- import { Button } from "@/components/ui/button";
- import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
- import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
- import { Separator } from "@/components/ui/separator";
- import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- } from "@/components/ui/dialog";
- import { Textarea } from "@/components/ui/textarea";
- import { Label } from "@/components/ui/label";
- import { ApproveAppointmentModal } from "@/components/appointments/ApproveAppointmentModal";
- import {
- Calendar,
- Clock,
- User,
- FileText,
- Video,
- CheckCircle2,
- XCircle,
- ArrowLeft,
- Loader2,
- AlertCircle,
- } from "lucide-react";
- import { format } from "date-fns";
- import { es } from "date-fns/locale";
- import { toast } from "sonner";
- import type { Appointment } from "@/types/appointments";
- import { canJoinMeeting, getAppointmentTimeStatus } from "@/utils/appointments";
- interface PageProps {
- params: Promise<{ id: string }>;
- }
- export default function AppointmentDetailPage({ params }: PageProps) {
- const router = useRouter();
- const { data: session, status } = useSession();
- const [appointment, setAppointment] = useState<Appointment | null>(null);
- const [loading, setLoading] = useState(true);
- const [approveDialog, setApproveDialog] = useState(false);
- const [rejectDialog, setRejectDialog] = useState(false);
- const [motivoRechazo, setMotivoRechazo] = useState("");
- const [actionLoading, setActionLoading] = useState(false);
- const [appointmentId, setAppointmentId] = useState<string>("");
- useEffect(() => {
- const loadParams = async () => {
- const resolvedParams = await params;
- setAppointmentId(resolvedParams.id);
- };
- loadParams();
- }, [params]);
- useEffect(() => {
- if (!appointmentId) return;
- const fetchAppointment = async () => {
- try {
- const response = await fetch(`/api/appointments/${appointmentId}`);
- if (!response.ok) {
- throw new Error("No se pudo cargar la cita");
- }
- const data: Appointment = await response.json();
- setAppointment(data);
- } catch (error) {
- toast.error("Error al cargar la cita");
- console.error(error);
- } finally {
- setLoading(false);
- }
- };
- fetchAppointment();
- }, [appointmentId]);
- if (status === "loading" || loading) {
- return (
- <AuthenticatedLayout>
- <div className="flex items-center justify-center min-h-screen">
- <Loader2 className="h-8 w-8 animate-spin" />
- </div>
- </AuthenticatedLayout>
- );
- }
- if (!session) {
- redirect("/auth/login");
- }
- if (!appointment) {
- return (
- <AuthenticatedLayout>
- <div className="container mx-auto px-4 py-6">
- <Card>
- <CardContent className="flex flex-col items-center justify-center py-12">
- <AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
- <h3 className="text-lg font-semibold mb-2">Cita no encontrada</h3>
- <p className="text-muted-foreground mb-4">
- La cita que buscas no existe o no tienes permisos para verla.
- </p>
- <Button asChild>
- <Link href="/appointments">Volver a mis citas</Link>
- </Button>
- </CardContent>
- </Card>
- </div>
- </AuthenticatedLayout>
- );
- }
- const userRole = session.user.role as "PATIENT" | "DOCTOR" | "ADMIN";
- const isPatient = userRole === "PATIENT";
- const isDoctor = userRole === "DOCTOR";
- const otherUser = isPatient ? appointment.medico : appointment.paciente;
- const hasFecha = appointment.fechaSolicitada !== null;
- const fecha = hasFecha ? new Date(appointment.fechaSolicitada!) : null;
- const handleApprove = async (fechaSolicitada: Date, notas?: string) => {
- setActionLoading(true);
- try {
- const response = await fetch(`/api/appointments/${appointment.id}/approve`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- fechaSolicitada: fechaSolicitada.toISOString(),
- notas,
- }),
- });
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.error || "Error al aprobar la cita");
- }
- const updated: Appointment = await response.json();
- setAppointment(updated);
- setApproveDialog(false);
- toast.success("Cita aprobada exitosamente");
- } catch (error) {
- toast.error(error instanceof Error ? error.message : "Error al aprobar la cita");
- console.error(error);
- } finally {
- setActionLoading(false);
- }
- };
- const handleRejectConfirm = async () => {
- if (!motivoRechazo.trim()) return;
- setActionLoading(true);
- try {
- const response = await fetch(`/api/appointments/${appointment.id}/reject`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ motivoRechazo }),
- });
- if (!response.ok) throw new Error("Error al rechazar la cita");
- const updated: Appointment = await response.json();
- setAppointment(updated);
- setRejectDialog(false);
- setMotivoRechazo("");
- toast.success("Cita rechazada");
- } catch (error) {
- toast.error("Error al rechazar la cita");
- console.error(error);
- } finally {
- setActionLoading(false);
- }
- };
- const handleCancel = async () => {
- setActionLoading(true);
- try {
- const response = await fetch(`/api/appointments/${appointment.id}`, {
- method: "DELETE",
- });
- if (!response.ok) throw new Error("Error al cancelar la cita");
- toast.success("Cita cancelada exitosamente");
- router.push("/appointments");
- } catch (error) {
- toast.error("Error al cancelar la cita");
- console.error(error);
- setActionLoading(false);
- }
- };
- const handleStartMeeting = async () => {
- setActionLoading(true);
- try {
- const response = await fetch(`/api/appointments/${appointment.id}/start-meeting`, {
- method: "POST",
- });
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.message || error.error || "No se puede iniciar la videollamada");
- }
- const data = await response.json();
-
- // Redirigir a la sala de Jitsi
- router.push(`/appointments/${appointment.id}/meet`);
- } catch (error) {
- toast.error(error instanceof Error ? error.message : "Error al iniciar videollamada");
- console.error(error);
- } finally {
- setActionLoading(false);
- }
- };
- const handleComplete = async () => {
- setActionLoading(true);
- try {
- const response = await fetch(`/api/appointments/${appointment.id}/complete`, {
- method: "POST",
- });
- if (!response.ok) throw new Error("Error al completar la cita");
- const updated: Appointment = await response.json();
- setAppointment(updated);
- toast.success("Cita marcada como completada");
- } catch (error) {
- toast.error("Error al completar la cita");
- console.error(error);
- } finally {
- setActionLoading(false);
- }
- };
- return (
- <AuthenticatedLayout>
- <div className="container mx-auto px-4 py-6 max-w-4xl">
- {/* Back Button */}
- <Button
- variant="ghost"
- className="mb-4"
- onClick={() => router.back()}
- >
- <ArrowLeft className="h-4 w-4 mr-2" />
- Volver
- </Button>
- {/* Header Card */}
- <Card className="mb-6">
- <CardHeader>
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-4">
- {otherUser && (
- <Avatar className="h-16 w-16">
- <AvatarImage src={otherUser.profileImage || undefined} />
- <AvatarFallback className="text-lg">
- {otherUser.name[0]}{otherUser.lastname[0]}
- </AvatarFallback>
- </Avatar>
- )}
- <div>
- <CardTitle className="text-2xl">
- {otherUser
- ? `${otherUser.name} ${otherUser.lastname}`
- : isDoctor
- ? "Sin asignar"
- : "Médico por asignar"}
- </CardTitle>
- <CardDescription>
- {isPatient ? "Médico asignado" : "Paciente"}
- </CardDescription>
- </div>
- </div>
- <AppointmentStatusBadge status={appointment.estado} />
- </div>
- </CardHeader>
- </Card>
- {/* Details Card */}
- <Card className="mb-6">
- <CardHeader>
- <CardTitle>Detalles de la Cita</CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- {hasFecha && fecha ? (
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
- <div className="flex items-start gap-3">
- <Calendar className="h-5 w-5 text-muted-foreground mt-0.5" />
- <div>
- <p className="text-sm font-medium">Fecha</p>
- <p className="text-sm text-muted-foreground">
- {format(fecha, "PPP", { locale: es })}
- </p>
- </div>
- </div>
- <div className="flex items-start gap-3">
- <Clock className="h-5 w-5 text-muted-foreground mt-0.5" />
- <div>
- <p className="text-sm font-medium">Hora</p>
- <p className="text-sm text-muted-foreground">
- {format(fecha, "p", { locale: es })}
- </p>
- </div>
- </div>
- </div>
- ) : (
- <div className="bg-muted/50 p-4 rounded-lg">
- <div className="flex items-start gap-3">
- <Clock className="h-5 w-5 text-muted-foreground mt-0.5" />
- <div>
- <p className="text-sm font-medium">Fecha y hora</p>
- <p className="text-sm text-muted-foreground italic">
- {appointment.estado === "PENDIENTE"
- ? "Pendiente de asignación por el médico"
- : "No asignada"}
- </p>
- </div>
- </div>
- </div>
- )}
- <Separator />
- <div className="flex items-start gap-3">
- <FileText className="h-5 w-5 text-muted-foreground mt-0.5" />
- <div className="flex-1">
- <p className="text-sm font-medium mb-1">Motivo de consulta</p>
- <p className="text-sm text-muted-foreground">
- {appointment.motivoConsulta}
- </p>
- </div>
- </div>
- {appointment.motivoRechazo && (
- <>
- <Separator />
- <div className="bg-destructive/10 p-4 rounded-lg">
- <div className="flex items-start gap-3">
- <XCircle className="h-5 w-5 text-destructive mt-0.5" />
- <div className="flex-1">
- <p className="text-sm font-medium text-destructive mb-1">
- Motivo de rechazo
- </p>
- <p className="text-sm text-muted-foreground">
- {appointment.motivoRechazo}
- </p>
- </div>
- </div>
- </div>
- </>
- )}
- {/* Solo mostrar sala si NO está completada */}
- {appointment.roomName && appointment.estado !== "COMPLETADA" && (
- <>
- <Separator />
- <div className="bg-primary/10 p-4 rounded-lg">
- <div className="flex items-start gap-3">
- <Video className="h-5 w-5 text-primary mt-0.5" />
- <div className="flex-1">
- <p className="text-sm font-medium mb-1">Sala de videollamada</p>
- <p className="text-sm text-muted-foreground mb-3">
- La sala está lista. Puedes unirte cuando llegue la hora de la cita.
- </p>
- <Button asChild size="sm">
- <Link href={`/appointments/${appointment.id}/meet`}>
- <Video className="h-4 w-4 mr-2" />
- Unirse a la consulta
- </Link>
- </Button>
- </div>
- </div>
- </div>
- </>
- )}
- {appointment.notasGuardadas && appointment.notasConsulta && (
- <>
- <Separator />
- <div className="bg-green-50 dark:bg-green-950 p-4 rounded-lg">
- <div className="flex items-start gap-3">
- <FileText className="h-5 w-5 text-green-700 dark:text-green-400 mt-0.5" />
- <div className="flex-1">
- <p className="text-sm font-medium text-green-900 dark:text-green-100 mb-1">
- Notas de la Consulta
- </p>
- {appointment.notasGuardadasAt && (
- <p className="text-xs text-green-700 dark:text-green-300 mb-2">
- Guardadas el {format(new Date(appointment.notasGuardadasAt), "d 'de' MMMM 'a las' HH:mm", { locale: es })}
- </p>
- )}
- <div className="text-sm text-green-900 dark:text-green-100 whitespace-pre-wrap bg-white/50 dark:bg-black/20 p-3 rounded">
- {appointment.notasConsulta}
- </div>
- </div>
- </div>
- </div>
- </>
- )}
- </CardContent>
- </Card>
- {/* Actions Card */}
- <Card>
- <CardHeader>
- <CardTitle>Acciones</CardTitle>
- </CardHeader>
- <CardContent>
- <div className="flex flex-wrap gap-3">
- {isDoctor && appointment.estado === "PENDIENTE" && (
- <>
- <Button
- onClick={() => setApproveDialog(true)}
- disabled={actionLoading}
- className="flex-1 min-w-[150px]"
- >
- {actionLoading ? (
- <Loader2 className="h-4 w-4 mr-2 animate-spin" />
- ) : (
- <CheckCircle2 className="h-4 w-4 mr-2" />
- )}
- Aprobar Cita
- </Button>
- <Button
- onClick={() => setRejectDialog(true)}
- variant="destructive"
- disabled={actionLoading}
- className="flex-1 min-w-[150px]"
- >
- <XCircle className="h-4 w-4 mr-2" />
- Rechazar Cita
- </Button>
- </>
- )}
- {isDoctor && appointment.estado === "APROBADA" && (
- <>
- {canJoinMeeting(appointment.fechaSolicitada).canJoin ? (
- <Button
- onClick={handleStartMeeting}
- disabled={actionLoading}
- className="flex-1 min-w-[150px]"
- >
- {actionLoading ? (
- <Loader2 className="h-4 w-4 mr-2 animate-spin" />
- ) : (
- <Video className="h-4 w-4 mr-2" />
- )}
- Unirse a Videollamada
- </Button>
- ) : (
- <Button
- disabled
- variant="outline"
- className="flex-1 min-w-[150px]"
- >
- <Clock className="h-4 w-4 mr-2" />
- {getAppointmentTimeStatus(appointment.fechaSolicitada)}
- </Button>
- )}
- <Button
- onClick={handleComplete}
- disabled={actionLoading}
- variant="outline"
- className="flex-1 min-w-[150px]"
- >
- {actionLoading ? (
- <Loader2 className="h-4 w-4 mr-2 animate-spin" />
- ) : (
- <CheckCircle2 className="h-4 w-4 mr-2" />
- )}
- Marcar como Completada
- </Button>
- </>
- )}
- {isPatient && appointment.estado === "PENDIENTE" && (
- <Button
- onClick={handleCancel}
- variant="outline"
- disabled={actionLoading}
- className="flex-1 min-w-[150px]"
- >
- {actionLoading ? (
- <Loader2 className="h-4 w-4 mr-2 animate-spin" />
- ) : (
- <XCircle className="h-4 w-4 mr-2" />
- )}
- Cancelar Cita
- </Button>
- )}
- {isPatient && appointment.estado === "APROBADA" && (
- <>
- {canJoinMeeting(appointment.fechaSolicitada).canJoin ? (
- <Button
- onClick={handleStartMeeting}
- disabled={actionLoading}
- className="flex-1 min-w-[150px]"
- >
- {actionLoading ? (
- <Loader2 className="h-4 w-4 mr-2 animate-spin" />
- ) : (
- <Video className="h-4 w-4 mr-2" />
- )}
- Unirse a Videollamada
- </Button>
- ) : (
- <Button
- disabled
- variant="outline"
- className="flex-1 min-w-[150px]"
- >
- <Clock className="h-4 w-4 mr-2" />
- {getAppointmentTimeStatus(appointment.fechaSolicitada)}
- </Button>
- )}
- </>
- )}
- {/* Acciones para citas completadas */}
- {appointment.estado === "COMPLETADA" && (
- <>
- {appointment.notasGuardadas && appointment.notasConsulta ? (
- <div className="flex-1 min-w-[150px] bg-green-50 dark:bg-green-950 p-4 rounded-lg">
- <div className="flex items-center gap-2 mb-2">
- <CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-400" />
- <p className="text-sm font-medium text-green-900 dark:text-green-100">
- Consulta Finalizada
- </p>
- </div>
- <p className="text-xs text-green-700 dark:text-green-300 mb-3">
- Las notas de la consulta están disponibles arriba
- </p>
- </div>
- ) : (
- <div className="flex-1 min-w-[150px] bg-muted p-4 rounded-lg">
- <div className="flex items-center gap-2 mb-2">
- <CheckCircle2 className="h-5 w-5 text-muted-foreground" />
- <p className="text-sm font-medium">
- Consulta Finalizada
- </p>
- </div>
- <p className="text-xs text-muted-foreground">
- Esta cita ha sido completada
- </p>
- </div>
- )}
- </>
- )}
- {/* Botón genérico de unirse (solo para APROBADA, no COMPLETADA) */}
- {appointment.estado === "APROBADA" && canJoinMeeting(appointment.fechaSolicitada).canJoin && (
- <Button asChild className="flex-1 min-w-[150px]">
- <Link href={`/appointments/${appointment.id}/meet`}>
- <Video className="h-4 w-4 mr-2" />
- Unirse a la Consulta
- </Link>
- </Button>
- )}
- </div>
- </CardContent>
- </Card>
- {/* Reject Dialog */}
- <Dialog open={rejectDialog} onOpenChange={setRejectDialog}>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>Rechazar Cita</DialogTitle>
- <DialogDescription>
- Por favor proporciona un motivo para rechazar esta cita. El paciente recibirá esta información.
- </DialogDescription>
- </DialogHeader>
- <div className="space-y-2">
- <Label htmlFor="motivo">Motivo del rechazo</Label>
- <Textarea
- id="motivo"
- value={motivoRechazo}
- onChange={(e) => setMotivoRechazo(e.target.value)}
- placeholder="Ejemplo: No hay disponibilidad en esta fecha, por favor reagenda para la próxima semana..."
- rows={4}
- className="resize-none"
- />
- </div>
- <DialogFooter>
- <Button
- variant="outline"
- onClick={() => {
- setRejectDialog(false);
- setMotivoRechazo("");
- }}
- >
- Cancelar
- </Button>
- <Button
- variant="destructive"
- onClick={handleRejectConfirm}
- disabled={!motivoRechazo.trim() || actionLoading}
- >
- {actionLoading ? (
- <Loader2 className="h-4 w-4 mr-2 animate-spin" />
- ) : (
- <XCircle className="h-4 w-4 mr-2" />
- )}
- Rechazar Cita
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- {/* Approve Dialog */}
- <ApproveAppointmentModal
- open={approveDialog}
- onClose={() => setApproveDialog(false)}
- onConfirm={handleApprove}
- isLoading={actionLoading}
- />
- </div>
- </AuthenticatedLayout>
- );
- }
|